Skip to content

NoSQL 与专用存储 ​

商品信息、搜索框、销售报表、监控曲线和用户上传的图片,虽然都叫“数据”,读写方式却完全不同。选择存储时,先说清楚一条请求要取什么、改什么、多久必须可见,再讨论产品。

本文把五类存储放回各自的工作中:MongoDB 如何组织文档,Elasticsearch 如何找到文本,ClickHouse 为什么适合大范围聚合,时序库如何管理持续产生的样本,以及 S3 / MinIO 如何保存对象。“NoSQL”不是这些产品共有的事务规则,也不是“不用 SQL”的同义词。 ClickHouse 本身提供 SQL,TimescaleDB 建立在 PostgreSQL 上。[1][2]

本文资料核对于 2026-10-13。MongoDB 页面当前标为 9.0;其他产品采用文末列出的当前官方文档。本次没有部署 MongoDB、Elasticsearch、ClickHouse、时序服务或 S3 / MinIO。正文的实际输出来自 Python 3.12.14 的教学模型,只验证倒排集合运算与聚合算术,不代表任何服务的执行计划、性能或一致性测试。

1. 先按访问方式分工 ​

请求自然的数据单位可考虑的存储得到的便利必须承担的代价
一次读取商品的可变属性和有限规格一份有嵌套结构的文档MongoDB按业务对象读写,字段可演进文档边界、索引、重复数据同步仍要设计
搜索“轻便 防水 背包”,按相关性排序词项到文档的映射Elasticsearch分词、匹配、评分和过滤映射、分词器、索引重建与可见延迟
汇总一年数十亿条事件的几个字段同一列的大量值ClickHouse压缩、列裁剪、批量计算排序键、批量写入、合并与更新语义
看某服务过去一小时的请求速率同一序列的一串时间样本时序库时间范围查询、保留期与降采样标签基数、迟到数据、统计口径
上传视频,按对象名下载或归档对象内容和元数据S3 / MinIO大对象、分段上传、生命周期管理不能直接按文件系统或关系事务使用

这是按需求作出的选择建议,不是产品性能排名。规模还小时,一套关系库可能已能满足文档、全文检索和时间范围查询;引入专用系统,要能说出它解决了哪项已测出的瓶颈。

一个常见组合如下。箭头表示数据去向,不代表跨系统事务:

正在绘制图示…

可切换适合窗口或原始尺寸
查看图源代码
flowchart LR
    W[业务写请求] --> D[主记录库:关系库或 MongoDB]
    D --> E[可靠变更流:含 ID 与版本]
    E --> S[Elasticsearch 搜索视图]
    E --> A[ClickHouse 分析视图]
    M[监控采集] --> T[时序库]
    U[文件上传] --> O[S3 或对象存储]
    O --> K[对象 key 与版本元数据]
    K --> D
    Q[搜索请求] --> S
    R[报表请求] --> A
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
flowchart LR
    W[业务写请求] --> D[主记录库:关系库或 MongoDB]
    D --> E[可靠变更流:含 ID 与版本]
    E --> S[Elasticsearch 搜索视图]
    E --> A[ClickHouse 分析视图]
    M[监控采集] --> T[时序库]
    U[文件上传] --> O[S3 或对象存储]
    O --> K[对象 key 与版本元数据]
    K --> D
    Q[搜索请求] --> S
    R[报表请求] --> A

这里的“可靠变更流”是架构要求:必须能重放、处理重复和检测积压。如果先写主库、再直接写搜索库,两次调用之间进程崩溃,就可能永久漏掉搜索更新。可采用主库支持的变更捕获,或在同一主库事务中保存待发布事件,再由消费者构建派生视图。消费者仍需按业务 ID 和版本处理重放。存储选型不能替代这段一致性设计。

2. MongoDB:先决定什么应该放在一份文档里 ​

MongoDB 以 BSON 文档保存数据。文档可以有嵌套对象和数组;同一集合中的文档不必具有完全相同的字段。这样做适合属性不完全相同、又经常一起读取的业务对象,但“灵活模式”不等于没有模式。字段含义、类型、必填项和迁移规则仍由应用负责,也可以用服务端 schema validation 约束。[3]

假设商品有名称、颜色和少量规格,详情页总是一起读取。把这些字段嵌入商品文档,可以减少分散读取,并让一次文档更新覆盖这些字段。若把一个商品的全部浏览事件也嵌进去,数组将持续增长,热点写入集中在同一文档,还会遇到 BSON 文档的 16 MiB 大小限制。这里应把事件拆成独立集合或送往分析存储。[3:1][4]

嵌入还是引用,取决于生命周期 ​

判断问题更偏向嵌入更偏向引用
数据是否总一起读商品与有限规格评论经常单独分页
数量是否有可靠上限一个订单的有限明细持续增长的事件列表
是否独立更新和复用下单时保存的收货地址快照多处复用的当前用户资料
是否必须一次原子修改同一文档内关联字段跨文档操作需要另行设计事务

地址快照值得特别区分:订单保留成交时地址,即使用户后来修改资料也不变,这是有意保存历史,不是“数据重复就一定设计错误”。相反,若复制的是必须随时一致的当前资料,就要支付传播更新的成本。

原子性要说清范围 ​

单文档写入是原子的,修改多个字段也不会只生效一半。但“先读库存,再把计算后的值写回”是两次操作,仍可能覆盖别人刚做的更新。需要把预期状态放进更新过滤条件,例如要求库存仍等于读到的值;适合累加的场景使用 $inc,也要把不得为负等业务条件放进过滤器。然后检查匹配或修改数量,不能只检查网络调用有没有报错。[5]

updateMany 对每份文档的修改是原子的,整个批次并不因此成为一个原子事务。MongoDB 支持副本集和分片集群的多文档事务,代价包括协调、冲突处理和更长的资源持有时间。先把文档边界设计正确,再为真正需要的跨文档不变量使用事务。[5:1]

实际操作常用入口应检查什么
按字段取文档find,投影、排序和 limit是否只取所需字段;过滤与排序是否有合适索引
按预期版本更新updateOne + 过滤条件 + 更新操作符匹配数量是否为 0;是否需要重读或报冲突
按步骤做统计aggregation pipeline 的 $match、$group、$project中间数据量;是否先减少输入;计划实际如何执行
强制业务唯一性unique index唯一键范围和历史脏数据,而非应用先查后写

文档模型没有免除索引设计。索引会加快相应查询,也增加写入与空间成本;用 explain 看扫描和返回数量,而不是见到“文档数据库”就默认无需建索引。[6][7]

从哪里读,与确认到什么程度,是两件事 ​

read preference 决定读向主节点还是副本;read concern 决定读取的隔离和可见性条件;write concern 决定写入要等到什么确认。副本异步复制,所以随意读 secondary 可能读到旧值。majority 也不能直接翻译成“所有节点此刻都一样”。需要读到自己的写入时,应按官方条件使用因果一致会话及相应的 majority 读写关注,并正确传递会话,不要只打开一个选项就宣称全局强一致。[8]

3. Elasticsearch:先把文本变成可检索的词项 ​

普通行索引常回答“哪个值等于 X”。全文检索还要回答“哪些文档谈到了这些词,谁更相关”。倒排索引把方向反过来:从词项找到包含它的文档,而不是逐篇扫描全文。Elasticsearch 的全文索引建立在 Lucene 上。[9]

分词决定你实际上在搜什么 ​

一个 analyzer 通常由字符过滤器、一个 tokenizer 和若干 token filters 组成:先处理字符,再切分 token,最后做小写转换等规范化。索引时和查询时的分析规则必须协调,否则用户输入的词项可能根本不在索引中。中文、型号、连字符和同义词尤其需要真实语料测试,不能把英文空格切分规则当作所有语言的答案。[10]

下面用小模型把这个方向看清。三份文档分别是 Go SQL database、Go search engine 和 SQL search index。模型只做英文小写和字母数字切词,然后建立集合:

python
for document_id, body in documents.items():
    for token in analyze(body):
        postings[token].add(document_id)

实际输出:

text
postings: {'go': [1, 2], 'sql': [1, 3], 'search': [2, 3]}
raw Go: []
analyzed Go!: [1, 2]
Go AND SQL: [1]

原样查 Go 没找到,是因为词项已经变成 go;先分析 Go! 则能找到文档 1、2。查询两个词并要求都出现,就是取两个集合的交集。这个模型刻意实现 AND,不是在声称 Elasticsearch 的 match 默认就是 AND。 实际 match 查询会分析输入,布尔组合条件由其参数决定。[11]

模型也没有位置、词频、删除、评分、分片或持久化。实际短语检索需要词项位置信息,相关性排序还需要评分机制。Elasticsearch 默认相似度可采用 BM25,它考虑词频、词项稀有程度和文档长度等因素;相关性分数不是业务上的可信度,也不能保证“分数最高就是用户最想要的”。[12]

字段或查询解决什么问题常见误用
text分析后的自然语言全文检索用它承担原样精确匹配、排序或聚合键
keyword状态码、分类、ID 等完整值的精确匹配;可配 normalizer把长文章当成一个词项,期待自然语言搜索
match对 text 查询输入做分析再匹配忘记匹配词数、AND/OR 和分析器会改变结果
term查索引中的精确词项,不按全文查询方式分析输入对已分词的 text 直接传一整句
同一字段的多字段映射一份值同时有全文和精确用途以为添加映射就自动重建全部历史数据

这些入口的语义以字段映射为前提。常见做法是标题主字段为 text,另有 keyword 子字段供精确用途;是否合适仍取决于值长度和查询目标。[13][14][15]

写成功不等于立刻能被搜索 ​

Elasticsearch 的 refresh 会打开新的可搜索段,使近期修改对搜索可见;它不是一次完整的磁盘提交。refresh、translog 持久性和副本确认是不同环节。不要把“刷新一次”当成跨文档事务,也不要为了让每次写后搜索立即命中,就不加评估地逐条强制 refresh。可见性等待会增加写入延迟和段管理成本。[9:1][16]

因此搜索结果通常是业务记录的派生视图。下单时的价格、库存和权限应向权威记录核实,不能仅凭几秒前的搜索结果决定。索引映射或分词规则改变时,还需考虑新建索引、回填、追增量、校验和切换,而不是只改一行配置。

4. ClickHouse:让大量行的少数列读得更便宜 ​

OLTP 常按主键找到少数行,再修改状态;OLAP 常扫描大量记录,按维度分组并求和。ClickHouse 面向后一类分析负载。列式存储把同一列的值组织在一起:报表只用时间、地区和金额时,不必同时读取图片说明等无关列;相近类型的数据也更便于压缩和批量处理。它仍支持 SQL,不能因为用途不同就笼统称为“不支持 SQL 的数据库”。[1:1]

MergeTree 的三个词不能混在一起 ​

MergeTree 家族把写入形成的有序 data parts 放在磁盘上,后台逐步合并。稀疏主索引定位的是一批行所在的范围,而不是为每一行都保存一个唯一定位项。因此能跳过不相关范围,也可能读入一些不满足条件的行再过滤。[17]

设计项主要作用不应期待它自动做什么
ORDER BY决定 part 内排序,影响数据局部性和查询剪枝不负责保证业务唯一性
PRIMARY KEY定义稀疏索引;省略时通常采用排序键不等于关系库唯一约束
PARTITION BY把数据拆成可管理的分区,方便生命周期操作不等于越细查询越快;过细会增加 parts 和管理成本

例如经常按租户查时间范围,排序键可评估 (tenant_id, event_time);按月淘汰历史数据,可另评估月份分区。这是工作负载驱动的设计示例,不是通用最佳组合。若主要查询变成“所有租户在某小时的总量”,原排序的收益可能就不同。[17:1]

小写入和更新,都会付出结构成本 ​

若每个事件单独提交,频繁创建小 parts 会增加后台合并压力。能接受短暂缓冲时,可在客户端攒批,或按部署版本评估异步插入;同时明确确认时机、故障时缓冲数据如何处理、重试是否会重复。单纯提高发送并发,不一定提高可持续吞吐。[18]

ClickHouse 能做更新和删除,但其具体机制随版本和引擎演进,不能沿用“完全不能 UPDATE”的旧说法,也不能把大量单行事务式更新的成本等同于 OLTP。对于持续导入的版本数据,ReplacingMergeTree 可以保留同一排序键的较新版本;这不是写入时唯一约束。后台合并不保证查询前已经去重,某些 parts 也未必继续自动合并。需要正确当前视图时,按模型使用 FINAL 或相应查询逻辑,并测量额外开销。[19]

这个区别直接影响报表:一次网络超时后的重试若插入两份相同业务事件,普通 sum(amount) 可能多算。业务事件 ID、版本、去重策略和查询方式需要一起设计。“数据迟早会合并”不能证明此刻数字正确。

5. 时序库:时间不是普通的附加字段 ​

时序数据围绕一个对象持续产生,例如某实例的内存值,或某设备的温度。基本问题不只是“保存时间戳”,还包括:同一序列如何定义、时间窗口如何聚合、过期数据如何处理、迟到样本如何修正历史结果。

以 Prometheus 为例,指标名和完整标签集共同标识一条时间序列,样本带时间戳和值。给同一个指标增加不同 instance 或 status 值,会产生不同序列;把用户 ID、请求 ID 这类不断增长的值作为标签,可能让序列数量失控。标签数量少,不代表基数小,关键在于实际不同取值组合有多少。[20][21]

时序方案自然的入口适合关注的问题需要接受或核实的边界
Prometheus指标、标签、PromQL服务监控、速率、告警标签基数;保留期和长期存储方案;不能把指标当逐条业务账本
InfluxDB 3时间戳、tags、fields,SQL / InfluxQL遥测和时序分析3 的存储与查询体系不能直接套用旧版教程;核实具体产品能力
TimescaleDBPostgreSQL 表、hypertable、时间分块时间数据还要与业务表用 SQL 联查扩展版本、chunk 大小、索引和维护成本

这三者不是同一种数据库换了名字。Prometheus 的模型围绕监控序列;InfluxDB 3 采用自己的时序与查询体系;TimescaleDB 把时间分块和生命周期管理带入 PostgreSQL 体系。需要复杂关联、需要哪种采集方式、能否接受某种保留策略,比“哪家叫时序库”更有判断价值。[20:1][22][2:1]

先分清数值含义,再选聚合函数 ​

Counter 表示累计量,通常只增加或在重启时归零;关心每秒请求量时,应使用能处理重置的速率语义,而不是直接平均累计值。Gauge 表示可以上升下降的当前状态,例如队列长度。延迟分布则需要 histogram 等结构;只有平均值,无法恢复尾延迟。[23]

降采样把原始样本汇成更粗的时间桶,减少存储和查询量,也会不可逆地丢信息。下面的教学数据有两个桶:第一个桶 9 个样本都是 0,第二个桶只有一个 100。

python
means = [sum(bucket) / len(bucket) for bucket in buckets]
mean_of_means = sum(means) / len(means)
weighted = sum(sum(bucket) for bucket in buckets) / sum(map(len, buckets))

实际输出:

text
mean-of-means=50.0 sample-weighted=10.0

简单平均两个桶的均值,让只有一个样本的桶获得了与九个样本相同的权重。若以后还要合并得到样本平均值,至少保留 sum 和 count。这个结论不能进一步推成“10 就是时间平均值”:不规则采样时,时间加权还取决于每个值代表的持续时间。要算 p99,则需要原始值或可合并的分布统计,sum / count 仍然不够。

生命周期也要把时间口径写清:按事件发生时间还是接收时间分桶?设备离线一天后补报,是否允许修正昨天的汇总?原始数据保留多久,汇总是否已成功?先删原始样本再发现汇总漏数,往往无法重算。不同产品的乱序写入窗口、聚合刷新和过期策略各不相同,不能拿一个时序库的默认设置代表全部产品。

6. S3 / MinIO:保存对象,不是把远程磁盘挂进程序 ​

本节讨论 S3 通用存储桶的常规对象 API。S3 还有 directory、table 等专门类型,不能把这里的操作边界套到所有新能力上。常规对象由 bucket、key、内容和元数据标识;key 中的斜杠通常只是命名的一部分,控制台显示的“文件夹”不意味着底层具有 POSIX 目录语义。[24][25]

操作对象存储中的做法与本地文件的区别
保存内容PUT 一个对象通常按对象替换,不是任意位置原地修改字节
读取大对象的一部分GET Range应明确请求范围;不能假定本地随机 I/O 的延迟
上传大文件multipart upload,完成后形成对象需要处理未完成分段及清理成本
改常规对象名称通常复制到新 key,再处理旧 key不是跨 key 的原子 rename
一组文件组成一个版本不变对象 + 清单对象应由应用设计版本发布,不能假定多个 PUT 一起提交

分段上传让不同部分可重试,不必失败一次就重传全部内容;但上传完成前的分段也需要生命周期管理。应用应保存合适的校验信息,不要把 ETag 永远当作文件的 MD5:分段上传或某些加密方式下,这种解释不成立。[26][27]

强一致也有对象边界 ​

Amazon S3 对成功的对象 PUT / DELETE 提供强读后写一致性,包含随后发起的 GET 和 LIST;同一 key 的更新是原子的,读到完整旧对象或新对象。它没有因此提供任意多个 key 的事务,也不会自动把你的关系库记录和对象上传合成一次提交。[28]

对并发覆盖,可评估条件写入:If-None-Match: * 只在目标不存在时创建;If-Match 按已有 ETag 检查预期版本。预条件失败要作为冲突处理,而不是无条件重试覆盖。这里使用 ETag 作为服务返回的条件比较值,不是在宣称它一定是内容 MD5。[29]

例如发布一套报表文件,可以先上传带版本号的不可变对象,再写一份清单引用它们,最后条件更新“当前版本”指针。读者先取指针,再按清单取对象。这个建议把发布点缩小到一个对象;它依赖所有被引用对象已成功写入、读者遵守协议、旧版本没有过早清理,不是服务自动赋予的多对象事务。

权限和成本同样是模型的一部分。桶不应为了方便下载而随意公开;预签名 URL 是带时效的访问能力,拿到的人可能在有效条件内使用,不能把它当作不会泄露权限的普通地址。还要考虑存储、请求、取回和传输成本,具体价格以所选区域、存储类别和服务商为准。[30][24:1]

MinIO 的接口兼容与产品现状,要分别核实 ​

MinIO 的定位是 S3 兼容对象存储,可以用于自建对象服务;“S3 兼容”表示要核实所用 API,而不是承诺所有 AWS 特性、运维能力和一致性配置都相同。自建还意味着自己负责容量、磁盘故障、升级、监控、备份与恢复演练。

截至本次核对,官方 minio/minio 社区仓库显示于 2026-04-25 归档,已只读;官方文档入口现在指向 AIStor。 不能继续把老社区安装教程当作当前持续维护承诺,也不能把商业 AIStor 的文档、支持或特性直接算到既有社区部署上。本文没有运行 MinIO,也没有验证任何特定二进制的兼容性。新选型应先核对具体发行物、许可证、更新来源和支持安排,再做接口与故障测试。[31][32]

7. 把“选了什么”变成可验证的承诺 ​

选型完成时,至少应能回答下面这些问题。它们也是后续测试的输入,而不是一次跑分能替代的清单。

要写下的承诺一份合格回答应包含什么
谁是权威记录哪个系统保存最终业务状态;哪些只是可重建视图
成功的含义收到确认时,到底完成了什么持久化、复制或索引可见性步骤
查询的含义精确值、相关性、样本平均、当前版本还是某时点快照
重试的结果如何识别重复;旧版本是否可能覆盖新版本;删除如何保留顺序
数据的寿命原始数据、汇总、对象版本和备份各保留多久,能否重建
故障的代价数据延迟、不可用时间、丢失窗口及恢复验证办法

专用存储的价值,是让某种访问方式更自然、更便宜。代价通常落在另一个地方:更多的数据复制、更复杂的更新规则,或者更重的维护责任。能把这些代价说清楚,才算完成了选型。

参考资料 ​

附录:完整教学模型与复现命令 ​

前提为 Python 3,无第三方依赖。保存为 models.py,运行 python3 models.py。本次 Python 3.12.14 / Linux 上输出与正文一致,断言全部通过。程序只使用内存,不连接、配置或写入外部服务。

models.py ​

代码块 · 37 行 · 点击收起 / 展开
python
"""Teaching models, not an Elasticsearch or time-series engine test."""
import re
from collections import defaultdict


def analyze(value):
    return re.findall(r"[a-z0-9]+", value.lower())


documents = {1: "Go SQL database", 2: "Go search engine", 3: "SQL search index"}
postings = defaultdict(set)
for document_id, body in documents.items():
    for token in analyze(body):
        postings[token].add(document_id)


def match_all(query):
    tokens = analyze(query)
    if not tokens:
        return []
    return sorted(set.intersection(*(postings[token] for token in tokens)))


print("postings:", {token: sorted(postings[token]) for token in ("go", "sql", "search")})
print("raw Go:", sorted(postings["Go"]))
print("analyzed Go!:", match_all("Go!"))
print("Go AND SQL:", match_all("Go SQL"))
assert match_all("Go SQL") == [1]
assert match_all("absent") == []
assert match_all("") == []

buckets = [[0] * 9, [100]]
means = [sum(bucket) / len(bucket) for bucket in buckets]
mean_of_means = sum(means) / len(means)
weighted = sum(sum(bucket) for bucket in buckets) / sum(map(len, buckets))
print(f"mean-of-means={mean_of_means:.1f} sample-weighted={weighted:.1f}")
assert mean_of_means == 50 and weighted == 10

  1. ClickHouse:Introduction。SQL、列式存储与分析负载。 ↩︎ ↩︎

  2. Tiger Data:Hypertables and chunks;Data lifecycle。 ↩︎ ↩︎

  3. MongoDB:Data Modeling。嵌入、引用、访问模式和 schema validation。 ↩︎ ↩︎

  4. MongoDB:Limits and Thresholds。BSON 文档大小边界。 ↩︎

  5. MongoDB:Atomicity and Transactions。单文档原子性、条件更新、多文档事务。 ↩︎ ↩︎

  6. MongoDB:Indexes。 ↩︎

  7. MongoDB:Aggregation。 ↩︎

  8. MongoDB:Read Isolation, Consistency, and Recency。读偏好、读写关注、会话条件。 ↩︎

  9. Elastic:Near real-time search。段与 refresh。 ↩︎ ↩︎

  10. Elastic:Anatomy of an analyzer。 ↩︎

  11. Elastic:Match query。本文限定普通 text 的词法全文查询。 ↩︎

  12. Elastic:Similarity module。BM25。 ↩︎

  13. Elastic:Text field type。 ↩︎

  14. Elastic:Keyword field type。 ↩︎

  15. Elastic:Term query。 ↩︎

  16. Elastic:Translog settings。持久性与 refresh 的区分。 ↩︎

  17. ClickHouse:MergeTree。parts、排序键、分区及非唯一稀疏主索引。 ↩︎ ↩︎

  18. ClickHouse:Selecting an insert strategy。 ↩︎

  19. ClickHouse:ReplacingMergeTree。版本、后台合并与 FINAL。 ↩︎

  20. Prometheus:Data model。 ↩︎ ↩︎

  21. Prometheus:Metric and label naming。标签基数。 ↩︎

  22. InfluxDB 3 Core 官方文档。 ↩︎

  23. Prometheus:Metric types。Counter、Gauge、Histogram。 ↩︎

  24. AWS:What is Amazon S3?。桶类型、对象、权限与功能边界。 ↩︎ ↩︎

  25. AWS:Naming Amazon S3 objects。key 与前缀。 ↩︎

  26. AWS:Multipart upload overview。 ↩︎

  27. AWS:Checking object integrity for data uploads。ETag 与校验和。 ↩︎

  28. AWS:Amazon S3 data consistency model。单 key 与跨 key 边界。 ↩︎

  29. AWS:Conditional writes。 ↩︎

  30. AWS:Presigned URLs。 ↩︎

  31. MinIO 官方社区仓库。本次读取到归档日期 2026-04-25。 ↩︎

  32. MinIO AIStor 当前官方文档。不作为旧社区发行物能力保证。 ↩︎

保留各书来源与许可 · 示例运行条件以正文为准